Skip to content

fix: gate public presentation serialization on display_on_site for media uploads - #577

Open
JpMaxMan wants to merge 9 commits into
mainfrom
fix/presentation-media-upload-public-leak
Open

fix: gate public presentation serialization on display_on_site for media uploads#577
JpMaxMan wants to merge 9 commits into
mainfrom
fix/presentation-media-upload-public-leak

Conversation

@JpMaxMan

@JpMaxMan JpMaxMan commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

ref: https://app.clickup.com/t/9014802374/86bb7zx8t

ref: https://app.clickup.com/t/9014802374/86bb6aem0

Summary

Public/anonymous callers to the events/published endpoints could pull full
PresentationMediaUpload data — including live public S3 URLs — for draft
(display_on_site=false) uploads via ?expand=media_uploads, because
PresentationSerializer read the unfiltered media uploads collection with no
visibility check.

Reported externally: a third party's calendar/speaker-scraping AI agent recovered
pre-event draft slide decks this way, with no malicious intent — it just added
expand=media_uploads to a normal public schedule call.

Closing that hole exposed three further defects in the same serializer, all fixed
here: privileged callers were losing uploads they were entitled to, the response
cache had no audience component, and the cache read had a check-then-act race.

Root cause

  • GET /summits/{id}/events/published and GET /summits/{id}/events/{event_id}/published
    are fully public/unauthenticated and forward a caller-supplied expand with no allowlist.
  • PresentationSerializer::serialize() handled expand=media_uploads (and the
    relations id-list path) by calling Presentation::getMediaUploads() — unfiltered —
    and serializing every attached upload's public_url regardless of draft state.
  • display_on_site exists on PresentationMaterial/PresentationMediaUpload for exactly
    this distinction (defaults false), and
    SummitMediaUploadType::getMediaUploadsToDisplayOnSite() was even built to filter on it —
    but had zero callers in the API/serializer stack.

What changed

1. Public callers only see approved uploads (90904aa91)

getVisibleMediaUploads() filters to display_on_site=true whenever the resolved
serializer type is Public. AdminPresentationCSVSerializer is intentionally untouched.

2. Privileged callers get their uploads back (7455f6006)

getMediaUploadsSerializerType() only recognised isAdmin() || memberCanEdit(), so two
callers that OAuth2SummitEventsApiController::getSerializerType() already treats as
privileged fell through to Public and lost every upload once the filter landed:

  • summit admins (summit-front-end-administrators). The event grid requests
    media_uploads.display_on_site, so the operator could no longer see the upload whose
    checkbox is the only thing that would make it visible again — circular.
  • the content-snapshot service account. pub-api reads with a client_credentials
    token, which carries no user_id, so getCurrentUser() is null by construction.

Service accounts are gated on a new scope, ReadAllPresentationMediaUploads, rather
than on ApplicationType_Service alone, which would have handed drafts to every service
client. The scope is registered with no endpoint association on purpose: endpoint
scopes are matched with array_intersect (any-of), so associating it would admit a token
holding only this scope to that endpoint.

3. media_uploads is never cached (b4079911e)

Visibility is resolved per user and per scope, but the cache key has no audience
component, so a payload built for one caller could be served to another. Adding the
serializer class to the key does not fix this case — a speaker and a plain attendee both
use PresentationSerializer, and a service account with and without the scope both use
AdminPresentationSerializer. So the field is stripped before Cache::put and recomputed
on every return through a new withMediaUploads(), on both the hit and miss paths.

4. The cache read no longer races (e09b148f3)

Cache::has() followed by Cache::get() returns null if the entry expires or is evicted
between the two, and json_decode(null, true) reaching withMediaUploads(array $values)
raised a TypeError. One Cache::get(); anything that does not decode to an array falls
through and is rebuilt.

5. The cache key gained a serializer and an unambiguous encoding (dea76db68) —
closes ClickUp 86bb6aem0

serialize() is inherited, not overridden, by AdminPresentationSerializer and the
track-chair and CSV serializers, and getAttributeMappings() merges mappings across the
hierarchy — so the stored payload carried rank, selection_status, streaming_url,
etherpad_link, overflow_stream_key, chair scores and vote stats. With no class in the
key, a public caller repeating an admin's query params inside the 1200s TTL read that
payload back verbatim.

The key is now presentation_{id}_{lastEditedTs}_{sha256}, with the digest over
static::class, expand, fields and relations. The parts are json_encoded rather
than joined on _, which also occurs inside media_uploads, extra_questions and
selection_planexpand=media_uploads&fields=x and expand=media&fields=uploads_x
previously rendered the same key. fields and relations are sorted first; expand is
deliberately not, because its relations are dispatched in order and the speakers and
moderator cases interact through moderator_speaker_id.

6. Serializer-type resolution memoized (23a45c253)

It ran once per media upload plus once per getVisibleMediaUploads() — 12 executions for
a ten-upload presentation, measured — and it reaches memberCanEdit(), which touches the
member's speaker and this presentation's speaker collection. Memoized per serializer
instance
, not per request: memberCanEdit() is answered against this presentation, so
a request-wide memo would hand every presentation in a list the first one's answer.

Deployment steps

⚠️ Order matters. Merging this before steps 1 and 2 empties the content snapshot: the
service account resolves Public, media_uploads comes back empty in events.json and
presentations.json, pub-api does not validate response shape so SnapshotCompleted still
fires, and dropbox-materializer stages nothing for every session — silently.

1. openstackid — register and grant the scope (outside this repo)

Register {SCOPE_BASE_REALM}/summits/presentations/media-uploads/read/all on the
summit-api resource server and grant it to the content-snapshot client.
SCOPE_BASE_REALM is config('app.scope_base_realm') per environment, e.g.
https://api.dev.fnopen.com/summits/presentations/media-uploads/read/all on dev.

2. pub-api — request the scope (outside this repo)

Append the same value to CONTENT_SNAPSHOT_OAUTH2_SCOPES
(backend/.env.template:22, read at backend/settings.py:321; space-separated) and
redeploy. Safe to do before summit-api ships — the current code simply ignores the extra
scope on the token.

3. summit-api — deploy this branch, then run the config migration

php artisan doctrine:migrations:migrate --em=config

Version20260804120000 registers the scope in api_scopes. It is idempotent
(WHERE NOT EXISTS) and reversible via down(). No endpoint_api_scopes row is created,
by design — see above. ApiScopesSeeder carries the same entry for fresh installs only.

4. Cache — no action

The key format change orphans existing entries; they age out on the 1200s TTL and rebuild
on demand. No flush, no coordination.

5. Verify after deploy

  • Anonymous ?expand=media_uploads on a published event returns only
    display_on_site=true uploads.
  • A summit admin loading the summit-admin event grid sees draft uploads again.
  • The next content snapshot has non-empty media_uploads, and dropbox-materializer stages
    files.

Test plan

Automated — tests/PresentationMediaUploadsVisibilityTest.php,
tests/PresentationSerializerCacheKeyTest.php, tests/PresentationMediaUploadsTest.php
(13 tests). CI runs them via the new PresentationMediaUploads matrix entry in push.yml;
the files sit in the tests/ root, which no existing job covered.

  • Public/anonymous, plain attendee, and a service account without the scope see only
    display_on_site=true uploads
  • Speaker on the presentation, summit admin, and a service account holding the scope see
    drafts too
  • An admin-shaped cached payload is not served to a public caller
  • Two requests that previously flattened to one key no longer share an entry
  • fields/relations order reuses one entry; expand order does not
  • An unavailable cached value degrades to a fresh build rather than a 500
  • A cache hit is still served, with media_uploads resolved fresh
  • Manual: summit-admin event grid shows draft uploads after deploy
  • Manual: content snapshot non-empty and dropbox-materializer stages files

Each automated test was checked against a deliberately broken implementation, so it is known
to fail when the behaviour regresses rather than assumed to.

OAuth2PresentationApiTest is unchanged from main: 42 tests, 204 assertions, 6 failures,
2 skipped. Those 6 are pre-existing (Doctrine\ORM\EntityNotFoundException on Member) and
untouched by this branch.

Out of scope / follow-up

  • This stops the URL from being served going forward — it does not revoke the
    public-read ACL already on previously-uploaded draft files. Rotating/regenerating
    already-exposed files (use_temporary_links_on_public_storage + the existing
    presentations-regenerate-media-uploads-temporal-public-urls command, or a longer-term
    private-storage migration) is a separate ops decision for @smarcet.
  • No display_on_site backfill. Data audit: 707 rows at 1, 4514 at 0; every summit since
    63 is at exactly zero approved. DocumentsComponent.js:23 filters the same flag client-side,
    so event-site already renders zero media uploads for those shows — the files have always been
    in the payload and never displayed. A blanket backfill would publish material deliberately
    left unapproved on 12/31/63, and on the shows at zero the flag means "never triaged" rather
    than "reviewed and rejected".

Summary by CodeRabbit

  • New Features
    • Added controlled access for presentation media uploads, including support for authorized service accounts with the appropriate read-all permission.
    • Public viewers and unauthorized callers see only approved, site-visible uploads.
  • Bug Fixes
    • Improved presentation response caching so media-upload visibility is evaluated for each request and private data is not inadvertently shared.
    • Cache handling now distinguishes requests with different access levels and expansion options.
  • Tests
    • Added coverage for media-upload visibility across viewer roles, permissions, and cached responses.

…dia uploads

Public/anonymous callers to the events/published endpoints could pull full
PresentationMediaUpload data -- including live public S3 URLs -- for
draft (display_on_site=false) uploads via ?expand=media_uploads, since
PresentationSerializer read the unfiltered media uploads collection.
Reported externally: a third party's calendar-scraping agent recovered
pre-event draft slide decks this way.

getVisibleMediaUploads() now reuses the existing admin/editor privilege
check to filter to display_on_site=true uploads for Public callers, at
all three call sites in this file. AdminPresentationCSVSerializer
(admin-only) is untouched.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Presentation media visibility

Layer / File(s) Summary
API scope definition
app/Security/SummitScopes.php
Adds ReadAllPresentationMediaUploads.
Serializer visibility and cache handling
app/ModelSerializers/Summit/Presentation/PresentationSerializer.php
Resolves media-upload visibility by caller. Normalizes cache keys and recomputes media_uploads for each request.
API scope registration
database/migrations/config/Version20260804120000.php, database/seeders/ApiScopesSeeder.php
Registers and seeds the new summit API scope.
Visibility and cache validation
tests/PresentationMediaUploadsTest.php, tests/PresentationMediaUploadsVisibilityTest.php, tests/PresentationSerializerCacheKeyTest.php, .github/workflows/push.yml
Adds local-storage fixtures, visibility tests, cache tests, and an integration-test matrix entry.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: smarcet

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant PresentationSerializer
  participant Cache
  Caller->>PresentationSerializer: Request presentation serialization
  PresentationSerializer->>Cache: Read request-shaped cached payload
  Cache-->>PresentationSerializer: Return cached fields or cache miss
  PresentationSerializer->>PresentationSerializer: Resolve visible media uploads
  PresentationSerializer-->>Caller: Return caller-specific presentation data
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 69.23% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: filtering public presentation media uploads by display_on_site during serialization.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/presentation-media-upload-public-leak

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-577/

This page is automatically updated on each push to this PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/ModelSerializers/Summit/Presentation/PresentationSerializer.php`:
- Line 153: Update PresentationSerializer cache-key generation to distinguish
public output from private/admin output resolved through
AdminPresentationSerializer, using a private serializer-specific key for private
data. Ensure cached relation IDs still pass through the appropriate visibility
rules before being returned, including the logic around getVisibleMediaUploads
and the related cache handling at the referenced later section.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6d0d53ee-95bd-4518-867f-495fd7dd1e9b

📥 Commits

Reviewing files that changed from the base of the PR and between 70ba47e and 90904aa.

📒 Files selected for processing (1)
  • app/ModelSerializers/Summit/Presentation/PresentationSerializer.php

Comment thread app/ModelSerializers/Summit/Presentation/PresentationSerializer.php Outdated
Comment thread app/ModelSerializers/Summit/Presentation/PresentationSerializer.php
Comment thread app/ModelSerializers/Summit/Presentation/PresentationSerializer.php
Comment thread app/ModelSerializers/Summit/Presentation/PresentationSerializer.php Outdated
Comment thread app/ModelSerializers/Summit/Presentation/PresentationSerializer.php Outdated

@smarcet smarcet left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JpMaxMan please review

smarcet added 3 commits August 4, 2026 10:51
…ice accounts

getMediaUploadsSerializerType() only recognised isAdmin() and memberCanEdit(),
so two callers that OAuth2SummitEventsApiController::getSerializerType() already
treats as privileged fell through to Public and lost every media upload once the
display_on_site filter landed:

- summit admins (summit-front-end-administrators). The event grid requests
  media_uploads.display_on_site, so the operator could no longer see the upload
  whose checkbox is the only thing that would make it visible again.
- the content-snapshot service account. pub-api reads media_uploads with a
  client_credentials token, which carries no user_id, so getCurrentUser() is
  null by construction; the snapshot emptied and dropbox-materializer, which
  does not filter on the flag itself, staged nothing for every session.

Service accounts are gated on a dedicated scope rather than on
ApplicationType_Service alone, which would have handed drafts to every service
client. The scope is registered with no endpoint association on purpose:
endpoint scopes are matched with array_intersect (any-of), so associating it
would admit a token holding only this scope to that endpoint. It is read
straight off the token and never consulted through endpoint_api_scopes.

Rollout order: the scope has to exist in openstackid and be granted to the
content-snapshot client, and be added to pub-api's CONTENT_SNAPSHOT_OAUTH2_SCOPES,
before this ships - until then that client still resolves Public.
The file is under tests/ root and no CI job filter covers it (push.yml runs
tests/oauth2/, tests/Unit/*, tests/Repositories/), so it rotted unnoticed and
failed on any environment. Three separate causes:

- setUp() read SummitMediaFileType via findAll() before insertSummitTestData(),
  which opens with DELETE FROM SummitMediaFileType. The entity it kept pointed
  at a deleted row, so the flush died on the SummitMediaUploadType.TypeID FK.
- self::$default_media_file_type is not a usable substitute: it carries ".PDF",
  while SummitMediaUploadType::isValidExtension() compares strtoupper($ext)
  against explode('|', ...), so a leading dot can never match. The test builds
  its own type declaring PNG, matching the png it uploads and the format the
  seeder uses (JPG|JPEG|PNG).
- the fixture declared Swift public storage, and serializing public_url builds
  a download strategy for it, which needs an authUrl that neither the local
  container nor CI provides. Local needs no credentials and the assertion is
  about public_url being serialized, not about the backend behind it.

Green and repeatable across consecutive runs.
… caller

getMediaUploadsSerializerType() resolves per user and per OAuth scope, but the
cache key is built from id + LastEditedUTC + expand + fields + relations and has
no audience component. A payload built for a privileged caller could therefore be
served verbatim to an unprivileged one within the 1200s TTL, handing out
display_on_site=false uploads the display_on_site filter was added to withhold.

Adding the serializer class to the key would not close it: a speaker on the
presentation and a plain attendee both serialize through PresentationSerializer,
and a service account with ReadAllPresentationMediaUploads and one without it both
serialize through AdminPresentationSerializer. Each pair shares a class and
disagrees on this field.

So the field is never stored. Cache::put receives a copy with media_uploads
removed, and a new private withMediaUploads() resolves it fresh on the way out of
every path -- cache hit, cache miss, and the non-cached branch alike. It opens by
unsetting the field, so a payload written before this change cannot leak one
either. Request shape is preserved: an id list for relations=media_uploads,
serialized objects for expand=media_uploads, expand winning when both are present.

This also removes the three scattered copies of that expansion logic, which were
the reason the cache-hit branch could drift from the others in the first place.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-577/

This page is automatically updated on each push to this PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/PresentationMediaUploadsTests.php (1)

54-55: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider a wider or non-repeating suffix for the test fixture name.

rand(1, 100) produces only 100 possible values. If the SummitMediaFileType name column enforces uniqueness, or if a prior test run left residual rows, this can collide and cause an intermittent test failure. The static analysis tool flags this as CWE-338, but that classification does not apply here — the value only names a local test fixture and does not protect any credential, token, or access-control decision. Use a wider random range or a non-repeating identifier to reduce flakiness.

♻️ Suggested change
-        $media_file_type->setName("PNG_".rand(1, 100));
+        $media_file_type->setName("PNG_".uniqid());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/PresentationMediaUploadsTests.php` around lines 54 - 55, The rand(1,
100) call in the SummitMediaFileType setName method on the media_file_type
object produces only 100 possible values, which risks collision and test
flakiness if the name column enforces uniqueness or prior test runs leave
residual data. Replace the narrow random range with a much wider range (such as
rand(1, 1000000)) or use a non-repeating identifier like uniqid() or
microtime(true) to ensure each test fixture gets a unique name.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/ModelSerializers/Summit/Presentation/PresentationSerializer.php`:
- Line 230: Update the cache-read flow in the serializer method containing
withMediaUploads() to decode Cache::get($key) once and invoke withMediaUploads()
only when the decoded value is an array; treat null or any unavailable value as
a cache miss and continue the existing miss path. Add a regression test that
removes the cache key between Cache::has() and retrieval.

---

Nitpick comments:
In `@tests/PresentationMediaUploadsTests.php`:
- Around line 54-55: The rand(1, 100) call in the SummitMediaFileType setName
method on the media_file_type object produces only 100 possible values, which
risks collision and test flakiness if the name column enforces uniqueness or
prior test runs leave residual data. Replace the narrow random range with a much
wider range (such as rand(1, 1000000)) or use a non-repeating identifier like
uniqid() or microtime(true) to ensure each test fixture gets a unique name.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3a9fa052-b9e6-4e59-aeaf-7f1cd7c20ca8

📥 Commits

Reviewing files that changed from the base of the PR and between 90904aa and b407991.

📒 Files selected for processing (5)
  • app/ModelSerializers/Summit/Presentation/PresentationSerializer.php
  • app/Security/SummitScopes.php
  • database/migrations/config/Version20260804120000.php
  • database/seeders/ApiScopesSeeder.php
  • tests/PresentationMediaUploadsTests.php

Comment thread app/ModelSerializers/Summit/Presentation/PresentationSerializer.php Outdated
smarcet added 2 commits August 4, 2026 11:41
…hp suffix

The testsuite in phpunit.xml scans ./tests/ with the default suffix, which is
Test.php, so a file ending in Tests.php is never collected. The class was
reachable only by passing its path explicitly - `--filter
PresentationMediaUploadsTests` answered "No tests executed!" - which is a large
part of why it rotted unnoticed until the fixture repair two commits ago.

The class is renamed alongside the file: autoload-dev maps Tests\ to tests/ via
PSR-4, so the two have to agree.

No call sites to update; nothing referenced the old name.
Six cases, one per branch, as asked for in the PR thread on this method:
anonymous, plain attendee, speaker on the presentation, summit admin, service
account holding ReadAllPresentationMediaUploads, and service account without it.

They assert on the output of serialize() - the media upload ids the caller
receives - rather than on the serializer-type string the method returns. What
the change is about is who sees an unapproved upload; the type is the mechanism,
and pinning it would tie the suite to the current implementation of a decision
that could be reached another way. The relations=media_uploads shape gives a
bare id list, so an assertion can name the exact uploads without dragging
PresentationMediaUploadSerializer, storage backends and public_url generation
into a unit test. Narrowing fields to id keeps the attribute-mapping loop off
every other getter on the mock.

Each case was checked against a broken implementation rather than assumed to
bite:

- dropping isSummitAdmin() from the member condition fails the summit admin case
  alone, which is the regression that blanked the admin event grid
- gating service accounts on ApplicationType_Service without the scope check
  fails the without-scope case alone
- removing the display_on_site filter from getVisibleMediaUploads() fails all
  three unprivileged cases

The suite also gets a matrix entry, by path: no job runs the tests/ root, only
its subdirectories, so both this file and the one renamed in the previous commit
would otherwise run nowhere.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-577/

This page is automatically updated on each push to this PR.

Cache::has() vouching for a key does not guarantee Cache::get() returns it: the
entry can reach its TTL, be evicted under memory pressure, or be dropped by a
flush in the window between the two calls. json_decode(null, true) is null, and
since the previous commit that null reaches withMediaUploads(array $values, ...)
and raises a TypeError, so the race now answers 500 where it used to answer a
quietly wrong payload. Only the voteable-presentation endpoints pass use_cache,
which is why this had not surfaced.

One Cache::get(), and anything that does not decode to an array falls through to
the normal build path - the race, an ordinary miss, and a truncated write all
take the same route, which is the one that was always going to be correct.

Two tests, each checked against a broken implementation rather than assumed to
bite:

- testUnavailableCachedValueIsTreatedAsAMiss reproduces the race and fails with
  exactly that TypeError against the previous code.
- testCacheHitIsServedButMediaUploadsAreResolvedFresh covers the other side,
  because the first test passes just as well against a serializer that has
  stopped reading the cache at all. It also pins the guarantee from the previous
  commit: returning the cached $values without recomputing makes the stale draft
  upload in the stored payload reach a public caller, which is the leak this
  branch exists to close.

It asserts on the payload rather than on how the cache was consulted, so it does
not have to be rewritten if that read changes shape again.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-577/

This page is automatically updated on each push to this PR.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR closes a data-leak path where public/anonymous callers could retrieve draft (display_on_site=false) PresentationMediaUpload details (including public URLs) from published event endpoints by requesting expand=media_uploads. It does so by gating which uploads are serialized based on caller privilege and the display_on_site flag, and introduces a dedicated scope for trusted service accounts.

Changes:

  • Filter presentation media uploads for public callers to only those with display_on_site=true, while preserving full visibility for admins/summit admins/editors and scoped service accounts.
  • Add ReadAllPresentationMediaUploads scope (constant + seeder + migration) to allow trusted service accounts to access draft uploads.
  • Add/adjust tests and CI workflow selection to cover the new visibility behavior and the cache-hit/miss behavior around media_uploads.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/PresentationMediaUploadsVisibilityTest.php Adds unit coverage for media upload visibility across anonymous/member/admin/service-scope branches and cache behavior.
tests/PresentationMediaUploadsTest.php Fixes test class naming and stabilizes media upload type setup for CI/runtime environments.
database/seeders/ApiScopesSeeder.php Registers the new ReadAllPresentationMediaUploads scope in seeded API scopes.
database/migrations/config/Version20260804120000.php Adds an idempotent migration to register the new scope (without endpoint association).
app/Security/SummitScopes.php Defines the new scope constant.
app/ModelSerializers/Summit/Presentation/PresentationSerializer.php Implements visible-upload filtering, service-scope privilege, and safer cache read/write behavior for media_uploads.
.github/workflows/push.yml Ensures the new root-level test file is executed in the CI matrix.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/ModelSerializers/Summit/Presentation/PresentationSerializer.php
Comment thread app/ModelSerializers/Summit/Presentation/PresentationSerializer.php
…encoding

Closes the audience gap the media-uploads work left behind. serialize() is
inherited, not overridden, by AdminPresentationSerializer and by the track-chair
and CSV serializers, so all of them write through this cache, and
getAttributeMappings() merges $array_mappings across the hierarchy - the stored
payload carries rank, selection_status, streaming_url, etherpad_link,
overflow_stream_key, chair scores and vote stats. The key named the presentation
and the request shape but never the serializer, so a public caller repeating an
admin's query params inside the 1200s TTL read the admin payload back verbatim.
GET /summits/{id}/presentations/voteable has no admin gate and resolves the
serializer per caller, which is what makes the sequence reachable.

The parts are also no longer joined on characters they contain. "_" separated
them while appearing inside media_uploads, extra_questions and selection_plan,
so distinct requests could render one key: expand=media_uploads&fields=x and
expand=media&fields=uploads_x both flattened to "..._media_uploads_x_". The
encoding is what fixes this, not the digest - hashing the old concatenation
preserves it exactly, which the test asserts. sha256 rather than md5 because
these parts come from the query string and a collision here means serving one
audience's payload to another.

fields and relations are sorted first. Both are consumed with in_array(), so
order cannot change the payload and two spellings of one request no longer cost
two entries. $expand is left alone on purpose: its relations are dispatched in
order, and the speakers and moderator cases both write $values['moderator']
while disagreeing about moderator_speaker_id, so normalising it could merge two
payloads that are allowed to differ. A test pins that decision.

The id and last_edited stay outside the digest so an update still busts every
entry a presentation has, and so an operator can scan or drop them by pattern.

No migration: the format change orphans existing entries, which age out on the
TTL and rebuild on demand.

The comment on the key carries the invariant this rests on - audience-dependent
data either appears in the key or stays out of the cache. static::class is
sufficient only while the remaining differences are class-determined, which is
true today because the mappings are static, getSerializerType() is constant per
class, and media_uploads is stripped before Cache::put.

Refs ClickUp 86bb6aem0.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-577/

This page is automatically updated on each push to this PR.

withMediaUploads() asks for the type once per media upload, and
getVisibleMediaUploads() asks again on top of that, so a presentation with ten
uploads resolved it twelve times for one response. The method reads the auth
context and then runs memberCanEdit(), which goes through the member's speaker
and this presentation's speaker collection - work that cannot change between two
calls on the same serializer for the same caller. Measured on a ten-upload
presentation serialized with expand and relations both naming media_uploads: 12
executions of the body before, 1 after.

The memo is instance-scoped, not request-scoped, and that distinction is the
whole point: memberCanEdit() is answered against THIS presentation, so a caller
can be a speaker on one and a stranger to the next, and a request-wide memo
would hand every presentation in a list the first one's answer. SerializerRegistry
builds a fresh serializer per object and none outlive the request, so
per-instance is both correct and enough.

Private rather than protected because TrackChairPresentationSerializer and
AdminPresentationCSVSerializer override the method with a constant and have
nothing to memo.

Also corrects the docblock, which opened by claiming this method is kept aligned
with OAuth2SummitEventsApiController::getSerializerType() and only qualified that
three paragraphs later. It is deliberately narrower for service accounts, which
need ReadAllPresentationMediaUploads here and nothing beyond the application type
there; a reader who stopped at the first sentence would conclude the opposite.

Both raised by Copilot on PR 577.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-577/

This page is automatically updated on each push to this PR.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Suppressed comments (2)

tests/PresentationMediaUploadsVisibilityTest.php:253

  • This comment says the stored payload has media_uploads absent, but the mocked cached JSON includes a media_uploads field. Update the comment to match what the test is actually exercising (dropping/recomputing a stale cached media_uploads value).
        // A payload as it is stored: media_uploads is absent by construction, and the stale value
        // is one no unprivileged caller may receive.

tests/PresentationMediaUploadsVisibilityTest.php:112

  • buildServiceContext() seeds scopes with the literal string '%s/summits/read', which is not a real scope value and makes the fixture less representative of production. Use the actual SummitScopes::ReadSummitData constant as the baseline scope instead.
        $scopes = ['%s/summits/read'];
        if ($with_scope) $scopes[] = SummitScopes::ReadAllPresentationMediaUploads;

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/PresentationMediaUploadsVisibilityTest.php (1)

31-33: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the expanded-media branch.

Lines 139-142 test only relations=['media_uploads']. This does not execute app/ModelSerializers/Summit/Presentation/PresentationSerializer.php Lines 201-215. Add a public expand=media_uploads regression test that asserts the draft upload is absent from expanded output.

Also applies to: 129-142

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/PresentationMediaUploadsVisibilityTest.php` around lines 31 - 33, Add a
new public test method to PresentationMediaUploadsVisibilityTest.php that
exercises the expanded-media code path in PresentationSerializer.php lines
201-215 by using expand=media_uploads parameter instead of the
relations=media_uploads shape currently tested in lines 139-142. The new test
should execute the same visibility scenario but verify that draft uploads are
absent from the expanded output to ensure the serializer correctly filters them
in both the bare id list and expanded representations.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/ModelSerializers/Summit/Presentation/PresentationSerializer.php`:
- Around line 275-285: The json_encode call within the hash operation can return
false if malformed UTF-8 is encountered, which PHP silently coerces to an empty
string before hashing, creating a cache collision vulnerability. Capture the
result of json_encode into a variable, validate that it is not false, and handle
the failure case by either rejecting the request, applying
JSON_INVALID_UTF8_SUBSTITUTE or similar safe encoding options to json_encode, or
skipping the cache key generation entirely. Only pass valid encoded data to the
hash function.

---

Nitpick comments:
In `@tests/PresentationMediaUploadsVisibilityTest.php`:
- Around line 31-33: Add a new public test method to
PresentationMediaUploadsVisibilityTest.php that exercises the expanded-media
code path in PresentationSerializer.php lines 201-215 by using
expand=media_uploads parameter instead of the relations=media_uploads shape
currently tested in lines 139-142. The new test should execute the same
visibility scenario but verify that draft uploads are absent from the expanded
output to ensure the serializer correctly filters them in both the bare id list
and expanded representations.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7f40fdf2-0cd2-4118-9a0e-4f9dd9a64bdd

📥 Commits

Reviewing files that changed from the base of the PR and between b407991 and 23a45c2.

📒 Files selected for processing (5)
  • .github/workflows/push.yml
  • app/ModelSerializers/Summit/Presentation/PresentationSerializer.php
  • tests/PresentationMediaUploadsTest.php
  • tests/PresentationMediaUploadsVisibilityTest.php
  • tests/PresentationSerializerCacheKeyTest.php

Comment on lines +275 to +285
hash
(
'sha256',
json_encode
([
'serializer' => static::class,
'expand' => $expand ?? "",
'fields' => $cache_fields,
'relations' => $cache_relations,
])
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

For PHP 8.3, what does json_encode() return for malformed UTF-8 by default, and how does hash('sha256', false) behave when strict types are not declared?

💡 Result:

In PHP 8.3, the behavior of json_encode and the hash function is as follows: json_encode and Malformed UTF-8: By default, if json_encode encounters a malformed UTF-8 character sequence, it fails and returns false [1][2]. The global error state will be set to JSON_ERROR_UTF8, which can be verified using json_last_error [2][3]. To alter this behavior, you can use flags such as JSON_INVALID_UTF8_IGNORE (which ignores the invalid characters) or JSON_INVALID_UTF8_SUBSTITUTE (which replaces them with the Unicode replacement character U+FFFD) [4][3]. Alternatively, using the JSON_THROW_ON_ERROR flag will cause the function to throw a JsonException instead of returning false [1][3]. hash with false and Strict Types: The hash function in PHP 8.3 has a signature of hash(string $algo, string $data, bool $binary = false, array $options = []): string [5][6]. When you call hash('sha256', false), PHP performs internal type juggling. Because the second argument is declared as a string in the function definition, the boolean false is cast to an empty string ("") [5][7]. Consequently, hash('sha256', false) behaves identically to hash('sha256', '') and returns the SHA-256 hash of an empty string, which is e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. This behavior remains consistent regardless of whether strict types are declared using declare(strict_types=1;); because the hash function is a built-in PHP function, the engine will still attempt to coerce the input to the required string type rather than throwing a TypeError.

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd -a 'PresentationSerializer.php' . | head -n1 || true)"
echo "FILE=$file"
if [ -n "${file:-}" ]; then
  wc -l "$file"
  sed -n '1,220p' "$file"
  echo "--- 220-320 ---"
  sed -n '220,320p' "$file"
fi

echo "--- declarations strict_types and call sites ---"
rg -n "declare\\(strict_types|function serialize|cacheFields|cacheRelations|cache\\(|hash\\(" .

Repository: OpenStackweb/summit-api

Length of output: 41789


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="app/ModelSerializers/Summit/Presentation/PresentationSerializer.php"
echo "--- PresentationSerializer.php header and relevant sections ---"
sed -n '1,180p' "$file"
echo "--- 180-340 ---"
sed -n '180,340p' "$file"

echo "--- Trait RequestScope/cache methods ---"
sed -n '1,120p' app/ModelSerializers/Traits/RequestScopedCache.php
echo "--- SerializerRegistry signature around global serialization ---"
sed -n '760,830p' app/ModelSerializers/SerializerRegistry.php

echo "--- behavioral probe for PHP hash(json_encode failure unless unavailable ---"
if command -v php >/dev/null 2>&1; then
  php -r '
    $encoded = json_encode(["x" => "\xFF"]);
    var_export($encoded);
    echo "\n";
    var_export(is_string($encoded));
    echo "\n";
    echo hash("sha256", $encoded);
    echo "\n";
    echo hash("sha256", false);
  '
else
  echo "php not available"
fi

Repository: OpenStackweb/summit-api

Length of output: 21189


Handle failed cache-key encoding before hashing.

If malformed UTF-8 reaches serialize(), json_encode() returns false, and PHP coerces that value to an empty string before hash('sha256', ...). Reject the request, use invalid-UTF8-safe encoding options, or build the key without caching.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/ModelSerializers/Summit/Presentation/PresentationSerializer.php` around
lines 275 - 285, The json_encode call within the hash operation can return false
if malformed UTF-8 is encountered, which PHP silently coerces to an empty string
before hashing, creating a cache collision vulnerability. Capture the result of
json_encode into a variable, validate that it is not false, and handle the
failure case by either rejecting the request, applying
JSON_INVALID_UTF8_SUBSTITUTE or similar safe encoding options to json_encode, or
skipping the cache key generation entirely. Only pass valid encoded data to the
hash function.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants